Skip to content

Add Payjoin Receiver Support#746

Open
Camillarhi wants to merge 1 commit intolightningdevkit:mainfrom
Camillarhi:payjoin-receiver
Open

Add Payjoin Receiver Support#746
Camillarhi wants to merge 1 commit intolightningdevkit:mainfrom
Camillarhi:payjoin-receiver

Conversation

@Camillarhi
Copy link
Contributor

@Camillarhi Camillarhi commented Jan 8, 2026

This PR adds support for receiving payjoin payments in LDK Node. This is currently a work in progress and implements the receiver side of the payjoin protocol.

  • Add session store for persisting payjoin receiver events across restarts
  • Implement KVStorePayjoinReceiverPersister to handle session persistence
  • Add Payjoin as a PaymentKind to the payment store
  • Add event polling mechanism for active payjoin sessions
  • Wire up payjoin payment request and receive flows

Note on persistence: The payjoin library currently only supports synchronous persistence, but they're working on adding async support(payjoin/rust-payjoin#1235). This PR sets up the persistence structure (KVStorePayjoinReceiverPersister), which will be updated to use async operations once the upstream PR is merged.

This PR will partially address #177

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Jan 8, 2026

I've assigned @tnull as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@DanGould
Copy link

We've merged the async persistence PR you mentioned. You might want to build your draft PR on the merged commit from there on until we cut you a release.

@Camillarhi
Copy link
Contributor Author

We've merged the async persistence PR you mentioned. You might want to build your draft PR on the merged commit from there on until we cut you a release.

Thanks for letting me know. I'll build on the merged commit.

@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 5 times, most recently from 8f6ba65 to 6499918 Compare January 30, 2026 10:36
@DanGould
Copy link

Are you stuck? Did something in our library break CI @Camillarhi

@Camillarhi
Copy link
Contributor Author

Are you stuck? Did something in our library break CI @Camillarhi

Not stuck at all. I was just closing out some other PRs. Still working on this one, I'll let you know when it's ready.

@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 2 times, most recently from 12a41ad to eb97832 Compare February 18, 2026 23:21
@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 3 times, most recently from 120b089 to 8cc2a31 Compare February 23, 2026 15:03
@Camillarhi Camillarhi force-pushed the payjoin-receiver branch 9 times, most recently from 1e1efcd to 5fe1a5c Compare March 10, 2026 12:06
Implements the receiver side of the BIP 77 Payjoin v2 protocol, allowing
LDK Node users to receive payjoin payments via a payjoin directory and
OHTTP relay.

- Adds a `PayjoinPayment` handler exposing a `receive()` method that returns
  a BIP 21 URI the sender can use to initiate the payjoin flow. The full
  receiver state machine is implemented covering all `ReceiveSession` states:
  polling the directory, validating the sender's proposal, contributing
  inputs, finalizing the PSBT, and monitoring the mempool.

- Session state is persisted via `KVStorePayjoinReceiverPersister` and
  survives node restarts through event log replay. Sender inputs are tracked
  by `OutPoint` across polling attempts to prevent replay attacks. The
  sender's fallback transaction is broadcast on cancellation or failure to
  ensure the receiver still gets paid.

- Adds `PaymentKind::Payjoin` to the payment store, `PayjoinConfig` for
  configuring the payjoin directory and OHTTP relay via
  `Builder::set_payjoin_config`, and background tasks for session resumption
  every 15 seconds and cleanup of terminal sessions after 24 hours.
@Camillarhi Camillarhi marked this pull request as ready for review March 12, 2026 09:50
@Camillarhi
Copy link
Contributor Author

Marking this as ready for review.

The core receiver flow is implemented, including session persistence, PSBT handling, input contribution, mempool monitoring for payjoin transactions, and node restart recovery.

Two things still pending that I'll follow up with:

  • Integration tests
  • Processing/updating confirmation state for payjoin transactions in the payment and pending payment stores

Happy to get early feedback on the overall approach in the meantime.

@tnull @DanGould

@ldk-reviews-bot ldk-reviews-bot requested a review from tnull March 12, 2026 09:51
Copy link

@bc1cindy bc1cindy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi there!

congrats and thanks for your work!

I talked to @spacebear21 and we agreed that I would review this PR, so I took a first look and here are some points:

needs to run cargo fmt --all

"InvalidLnurl",
"PayjoinNotConfigured",
"PayjoinSessionCreationFailed",
"PayjoinSessionFailed"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PayjoinSessionFailed is used for infrastructure failures (coin selection, InputPair construction) and network errors (poll/post requests). The upstream lib distinguishes these via SelectionError, InputContributionError, and ReceiverError , would it be worth preserving that granularity so callers have more info and know how to act?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the errors exposed at the ldk-node API level. More granular details from the upstream errors are logged for debugging purposes.


pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
let timeout_fut = tokio::time::timeout(
Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since testmempoolaccept is local validation only, shouldn't this use DEFAULT_TX_LOOKUP_TIMEOUT_SECS?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, will update to use DEFAULT_TX_LOOKUP_TIMEOUT_SECS

Err(e) => {
// Check if it's a "not found" error
let error_str = e.to_string();
if error_str.contains("No such mempool or blockchain transaction")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matching errors by string seems fragile, as the message varies by server implementation.
e.g. tested: ssl://electrum.blockstream.info:60002: Protocol(String("missing transaction")) , which doesn't match either string here and Ok(None) would never be returned. the caller sees a sync failure instead

self.runtime.spawn_blocking(move || electrum_client.transaction_get(&txid_copy));
let timeout_fut = tokio::time::timeout(
Duration::from_secs(
self.sync_config.timeouts_config.lightning_wallet_sync_timeout_secs,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a single onchain tx lookup, should this use tx_broadcast_timeout_secs (10s) instead of lightning_wallet_sync_timeout_secs (30s)? or would a dedicated tx_lookup_timeout_secs field make more sense?

&self, proposal: Receiver<Monitor>, persister: &KVStorePayjoinReceiverPersister,
) -> Result<(), Error> {
// On a session resumption, the receiver will resume again in this state.
let poll_interval = tokio::time::Duration::from_millis(200);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems aggressive for a daemon (payjoin-cli uses 200ms because its a one-shot CLI with manual retry) but for ldk node maybe 1-5 seconds would be more appropriate?

})
.save_async(persister)
.await
.map_err(|_| Error::PersistenceFailed)?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finalize_proposal returns MaybeTransientTransition to distinguish transient from fatal errors, but .map_err(|_| Error::PersistenceFailed) collapses both into the same error. Transient failures won't be retried and will surface as PersistenceFailed, which is misleading

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PersistenceFailed here doesn't cancel the session. The session remains active in its current state and will be retried on the next poll cycle. Sessions are only marked as failed when we receive a Closed event. Retrying transient failures is handled naturally by the polling loop.

(4, secret, option),
}
},
(11, Payjoin)=>{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all other PaymentKind variants use even TLV tags (0, 2, 4, 6, 8, 10), was 11 intentional, or should this be 12?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11 is intentional here. Since this is newly added, we're following the "it's okay to be odd" rule, which allows odd TLV types for optional fields that can be safely ignored by older readers. So 11 is correct rather than 12.

Arc::clone(&kv_store),
Arc::clone(&logger),
)),
Err(e) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payjoin_session_store_res doesn't handle ErrorKind::NotFound, which will occur on any node that has never used payjoin. fix suggestion in read_payjoin_sessions

Copy link
Contributor Author

@Camillarhi Camillarhi Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, but the list() implementations across all return Ok(vec![]) when the namespace is empty or hasn't been written to yet.

#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "869fd348c3ca0c78f439d2f31181f4d798c6b20e" }

payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", package = "payjoin", default-features = false, features = ["v2", "io"] }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this pin a rev?

PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE,
PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE,
)
.await?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list() returns NotFound when the namespace has never been written, maybe consider something like this handling it?

rust.or_else(|e| if e.kind() == lightning::io::ErrorKind::NotFound {
    Ok(vec![])
} else {
    Err(e)
})?;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, but the list() implementations across all return Ok(vec![]) when the namespace is empty or hasn't been written to yet.

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @tnull! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@Camillarhi
Copy link
Contributor Author

hi there!

congrats and thanks for your work!

I talked to @spacebear21 and we agreed that I would review this PR, so I took a first look and here are some points:

needs to run cargo fmt --all

Thanks for the review. cargo fmt --all has already been run, otherwise the CI would be failing. I am happy to double-check if you're seeing something specific

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @tnull! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

SessionOutcome::Failure => {
log_error!(self.logger, "Payjoin session failed due to a protocol error.");

let fallback_tx = session.fallback_tx.as_ref().ok_or(Error::InvalidPaymentId)?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_session_fatal_error covers Failure with fallback_tx: None, when check_broadcast_suitability rejects the PSBT. In that case, the ? returns before failed status is persisted

@bc1cindy
Copy link

hi there!
congrats and thanks for your work!
I talked to @spacebear21 and we agreed that I would review this PR, so I took a first look and here are some points:
needs to run cargo fmt --all

Thanks for the review. cargo fmt --all has already been run, otherwise the CI would be failing. I am happy to double-check if you're seeing something specific

I'm using as default nightly toolchain and ldk node uses stable, so that's what was happening

Copy link

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great! I have high-level feedback from the payjoin side, but overall agree with the approach and implementation.

Comment on lines +479 to +489
ChainSourceKind::Esplora{..} => {
// Esplora does not support a testmempoolaccept equivalent, so we skip
// the broadcast suitability check and assume the transaction is broadcastable.
log_debug!(self.logger, "Skipping broadcast suitability check: not supported by Esplora backend.");
Ok(true)
},
ChainSourceKind::Electrum{..} => {
// Electrum does not support a testmempoolaccept equivalent, so we skip
// the broadcast suitability check and assume the transaction is broadcastable.
log_debug!(self.logger, "Skipping broadcast suitability check: not supported by Electrum backend.");
Ok(true)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broadcast suitability check is important for non-interactive receivers (e.g. payment processors) to protect against probing attacks, where a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs it owns with the proposals it modifies. The "broadcastable" check ensures the receiver can broadcast the fallback transaction in case the payjoin fails, effectively incurring a cost to the attacker.

Electrum's lack of support for this kind of check is a longstanding issue unfortunately.

I'm not familiar enough with LDK-node usage to know if it's typically used in an interactive way (receiver takes manual action to receive payment) or non-interactive (e.g. bolt12 or similar static payment with no receiver action per payment)?

In interactive conditions, the receiver may use the assume_interactive_receiver transition method to explicitly skip the broadcast check altogether. In non-interactive conditions though, we should find an alternative way to perform this check. Or at least return an error instead of silently skipping the check, so it can be handled by the implementer.

// is assumed to be broadcastable.
let proposal = proposal
.check_broadcast_suitability(None, |tx| {
self.chain_source

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So here for example we could call proposal.assume_interactive_receiver() if applicable.

Comment on lines +379 to +385
Error::PayjoinSessionFailed
})?;
let proposal = proposal
.contribute_inputs(vec![selected_input])
.map_err(|e| {
log_error!(self.logger, "Failed to contribute inputs to payjoin: {}", e);
Error::PayjoinSessionFailed

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these failure modes handled by broadcasting the fallback transaction? I'm trying to find where these errors are handled but not sure I'm tracing it correctly.

Comment on lines +645 to +646
/// The URL of the OHTTP relay to use for sending OHTTP requests to PayJoin receivers.
pub ohttp_relay: URL,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make this a list of OHTTP relays and randomize the relay per-request. This also improves reliability by enabling retries to other relays in case one is offline for any reason.

.payjoin_session_store
.list_filter(|s| {
let is_terminal =
s.status == PayjoinStatus::Completed || s.status == PayjoinStatus::Failed;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be desirable to keep "Completed" sessions ~forever since it makes it possible to reconstruct past Payjoins for e.g. displaying transaction history.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants